/*
 * Copyright 2019 WeBank
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */

// https://github.com/tangbc/vue-virtual-scroll-list

const _debounce = (func, wait, immediate) => {
  let timeout
  return function () {
    const context = this
    const args = arguments
    const later = function () {
      timeout = null
      if (!immediate) {
        func.apply(context, args)
      }
    }
    const callNow = immediate && !timeout
    clearTimeout(timeout)
    timeout = setTimeout(later, wait)
    if (callNow) {
      func.apply(context, args)
    }
  }
}

export default {
  props: {
    size: {
      type: Number,
      required: true
    },
    remain: {
      type: Number,
      required: true
    },
    rtag: {
      type: String,
      default: 'div'
    },
    wtag: {
      type: String,
      default: 'div'
    },
    wclass: {
      type: String,
      default: ''
    },
    wstyle: {
      type: Object,
      default: () => ({})
    },
    pagemode: {
      type: Boolean,
      default: false
    },
    scrollelement: {
      type: typeof window === 'undefined' ? Object : HTMLElement,
      default: null
    },
    start: {
      type: Number,
      default: 0
    },
    offset: {
      type: Number,
      default: 0
    },
    variable: {
      type: [Function, Boolean],
      default: false
    },
    bench: {
      type: Number,
      default: 0 // also equal to remain
    },
    debounce: {
      type: Number,
      default: 0
    },
    totop: {
      type: [Function, Boolean],
      default: false
    },
    tobottom: {
      type: [Function, Boolean],
      default: false
    },
    onscroll: {
      type: [Function, Boolean], // Boolean disables default behavior
      default: false
    },
    istable: {
      type: Boolean,
      default: false
    },
    item: {
      type: [Function, Object],
      default: null
    },
    itemcount: {
      type: Number,
      default: 0
    },
    itemprops: {
      type: Function,
      /* istanbul ignore next */
      default () {}
    }
  },

  // use changeProp to identify the prop change.
  watch: {
    size () {
      this.changeProp = 'size'
    },
    remain () {
      this.changeProp = 'remain'
    },
    bench () {
      this.changeProp = 'bench'
      this.itemModeForceRender()
    },
    start () {
      this.changeProp = 'start'
      this.itemModeForceRender()
    },
    offset () {
      this.changeProp = 'offset'
      this.itemModeForceRender()
    },
    itemcount () {
      this.changeProp = 'itemcount'
      this.itemModeForceRender()
    },
    scrollelement (newScrollelement, oldScrollelement) {
      if (this.pagemode) {
        return
      }
      if (oldScrollelement) {
        this.removeScrollListener(oldScrollelement)
      }
      if (newScrollelement) {
        this.addScrollListener(newScrollelement)
      }
    }
  },

  created () {
    const start = this.start >= this.remain ? this.start : 0
    const keeps = this.remain + (this.bench || this.remain)

    const delta = Object.create(null)

    delta.direction = '' // current scroll direction, D: down, U: up.
    delta.scrollTop = 0 // current scroll top, use to direction.
    delta.start = start // start index.
    delta.end = start + keeps - 1 // end index.
    delta.keeps = keeps // nums keeping in real dom.
    delta.total = 0 // all items count, update in filter.
    delta.offsetAll = 0 // cache all the scrollable offset.
    delta.paddingTop = 0 // container wrapper real padding-top.
    delta.paddingBottom = 0 // container wrapper real padding-bottom.
    delta.varCache = {} // object to cache variable index height and scroll offset.
    delta.varAverSize = 0 // average/estimate item height before variable be calculated.
    delta.varLastCalcIndex = 0 // last calculated variable height/offset index, always increase.

    this.delta = delta
  },

  mounted () {
    if (this.pagemode) {
      this.addScrollListener(window)
    } else if (this.scrollelement) {
      this.addScrollListener(this.scrollelement)
    }

    if (this.start) {
      const start = this.getZone(this.start).start
      this.setScrollTop(this.variable ? this.getVarOffset(start) : start * this.size)
    } else if (this.offset) {
      this.setScrollTop(this.offset)
    }
  },

  beforeDestroy () {
    if (this.pagemode) {
      this.removeScrollListener(window)
    } else if (this.scrollelement) {
      this.removeScrollListener(this.scrollelement)
    }
  },

  // check if delta should update when props change.
  beforeUpdate () {
    const delta = this.delta
    delta.keeps = this.remain + (this.bench || this.remain)

    const calcstart = this.changeProp === 'start' ? this.start : delta.start
    const zone = this.getZone(calcstart)

    // if start, size or offset change, update scroll position.
    if (this.changeProp && ['start', 'size', 'offset'].includes(this.changeProp)) {
      const scrollTop = this.changeProp === 'offset'
        ? this.offset : this.variable
          ? this.getVarOffset(zone.isLast ? delta.total : zone.start)
          : zone.isLast && (delta.total - calcstart <= this.remain)
            ? delta.total * this.size : calcstart * this.size

      this.$nextTick(this.setScrollTop.bind(this, scrollTop))
    }

    // if points out difference, force update once again.
    if (
      this.changeProp ||
        delta.end !== zone.end ||
        calcstart !== zone.start
    ) {
      this.changeProp = ''
      delta.end = zone.end
      delta.start = zone.start
      this.forceRender()
    }
  },

  methods: {
    // add pagemode/scrollelement scroll event listener
    addScrollListener (element) {
      this.scrollHandler = this.debounce ? _debounce(this.onScroll.bind(this), this.debounce) : this.onScroll
      element.addEventListener('scroll', this.scrollHandler, false)
    },

    // remove pagemode/scrollelement scroll event listener
    removeScrollListener (element) {
      element.removeEventListener('scroll', this.scrollHandler, false)
    },

    onScroll (event) {
      const delta = this.delta
      const vsl = this.$refs.vsl
      let offset
      if (this.pagemode) {
        const elemRect = this.$el.getBoundingClientRect()
        offset = -elemRect.top
      } else if (this.scrollelement) {
        const scrollelementRect = this.scrollelement.getBoundingClientRect()
        const elemRect = this.$el.getBoundingClientRect()
        offset = scrollelementRect.top - elemRect.top
      } else {
        offset = (vsl && (vsl.$el || vsl).scrollTop) || 0
      }

      delta.direction = offset > delta.scrollTop ? 'D' : 'U'
      delta.scrollTop = offset

      if (delta.total > delta.keeps) {
        this.updateZone(offset)
      } else {
        delta.end = delta.total - 1
      }

      const offsetAll = delta.offsetAll
      if (this.onscroll) {
        const param = Object.create(null)
        param.offset = offset
        param.offsetAll = offsetAll
        param.start = delta.start
        param.end = delta.end
        this.onscroll(event, param)
      }

      if (!offset && delta.total) {
        this.fireEvent('totop')
      }

      if (offset >= offsetAll) {
        this.fireEvent('tobottom')
      }
    },

    // update render zone by scroll offset.
    updateZone (offset) {
      const delta = this.delta
      let overs = this.variable
        ? this.getVarOvers(offset)
        : Math.floor(offset / this.size)

      // if scroll up, we'd better decrease it's numbers.
      if (delta.direction === 'U') {
        overs = overs - this.remain + 1
      }

      const zone = this.getZone(overs)
      const bench = this.bench || this.remain

      // for better performance, if scroll passes items within the bench, do not update.
      // and if it's close to the last item, render next zone immediately.
      const shouldRenderNextZone = Math.abs(overs - delta.start - bench) === 1
      if (
        !shouldRenderNextZone &&
          (overs - delta.start <= bench) &&
          !zone.isLast && (overs > delta.start)
      ) {
        return
      }

      // make sure forceRender calls as less as possible.
      if (
        shouldRenderNextZone ||
          zone.start !== delta.start ||
          zone.end !== delta.end
      ) {
        delta.end = zone.end
        delta.start = zone.start
        this.forceRender()
      }
    },

    // return the right zone info based on `start/index`.
    getZone (index) {
      let start, end
      const delta = this.delta

      index = parseInt(index, 10)
      index = Math.max(0, index)

      const lastStart = delta.total - delta.keeps
      const isLast = (index <= delta.total && index >= lastStart) || (index > delta.total)

      if (isLast) {
        start = Math.max(0, lastStart)
      } else {
        start = index
      }
      end = start + delta.keeps - 1
      if (delta.total && end > delta.total) {
        end = delta.total - 1
      }

      return {
        end,
        start,
        isLast
      }
    },

    // public method, force render ui list if needed.
    // call this before the next rerender to get better performance.
    forceRender () {
      window.requestAnimationFrame(() => {
        this.$forceUpdate()
      })
    },

    // force render ui if using item-mode.
    itemModeForceRender () {
      if (this.item) {
        this.forceRender()
      }
    },

    // return the scroll of passed items count in variable.
    getVarOvers (offset) {
      let low = 0
      let middle = 0
      let middleOffset = 0
      const delta = this.delta
      let high = delta.total

      while (low <= high) {
        middle = low + Math.floor((high - low) / 2)
        middleOffset = this.getVarOffset(middle)

        // calculate the average variable height at first binary search.
        if (!delta.varAverSize) {
          delta.varAverSize = Math.floor(middleOffset / middle)
        }

        if (middleOffset === offset) {
          return middle
        } else if (middleOffset < offset) {
          low = middle + 1
        } else if (middleOffset > offset) {
          high = middle - 1
        }
      }

      return low > 0 ? --low : 0
    },

    // return a variable scroll offset from given index.
    getVarOffset (index, nocache) {
      const delta = this.delta
      const cache = delta.varCache[index]

      if (!nocache && cache) {
        return cache.offset
      }

      let offset = 0
      for (let i = 0; i < index; i++) {
        const size = this.getVarSize(i, nocache)
        delta.varCache[i] = {
          size: size,
          offset: offset
        }
        offset += size
      }

      delta.varLastCalcIndex = Math.max(delta.varLastCalcIndex, index - 1)
      delta.varLastCalcIndex = Math.min(delta.varLastCalcIndex, delta.total - 1)

      return offset
    },

    // return a variable size (height) from given index.
    getVarSize (index, nocache) {
      const cache = this.delta.varCache[index]
      if (!nocache && cache) {
        return cache.size
      }

      if (typeof this.variable === 'function') {
        return this.variable(index) || 0
      } else {
        // when using item, it can only get current components height,
        // need to be enhanced, or consider using variable-function instead
        const slot = this.item
          ? (this.$children[index] ? this.$children[index].$vnode : null)
          : this.$slots.default[index]

        const style = slot && slot.data && slot.data.style
        if (style && style.height) {
          const shm = style.height.match(/^(.*)px$/)
          return (shm && +shm[1]) || 0
        }
      }
      return 0
    },

    // return the variable paddingTop based on current zone.
    // @todo: if set a large `start` before variable was calculated,
    // here will also case too much offset calculate when list is very large,
    // consider use estimate paddingTop in this case just like `getVarPaddingBottom`.
    getVarPaddingTop () {
      return this.getVarOffset(this.delta.start)
    },

    // return the variable paddingBottom based on the current zone.
    getVarPaddingBottom () {
      const delta = this.delta
      const last = delta.total - 1
      if (delta.total - delta.end <= delta.keeps || delta.varLastCalcIndex === last) {
        return this.getVarOffset(last) - this.getVarOffset(delta.end)
      } else {
        // if unreached last zone or uncalculated real behind offset
        // return the estimate paddingBottom and avoid too much calculations.
        return (delta.total - delta.end) * (delta.varAverSize || this.size)
      }
    },

    // return the variable all heights use to judge reach bottom.
    getVarAllHeight () {
      const delta = this.delta
      if (delta.total - delta.end <= delta.keeps || delta.varLastCalcIndex === delta.total - 1) {
        return this.getVarOffset(delta.total)
      } else {
        return this.getVarOffset(delta.start) + (delta.total - delta.end) * (delta.varAverSize || this.size)
      }
    },

    // public method, allow the parent update variable by index.
    updateVariable (index) {
      // clear/update all the offsets and heights ahead of index.
      this.getVarOffset(index, true)
    },

    // trigger a props event on parent.
    fireEvent (event) {
      if (this[event]) {
        this[event]()
      }
    },

    // set manual scroll top.
    setScrollTop (scrollTop) {
      if (this.pagemode) {
        window.scrollTo(0, scrollTop)
      } else if (this.scrollelement) {
        this.scrollelement.scrollTo(0, scrollTop)
      } else {
        const vsl = this.$refs.vsl
        if (vsl) {
          (vsl.$el || vsl).scrollTop = scrollTop
        }
      }
    },

    // filter the shown items based on `start` and `end`.
    filter (h) {
      const delta = this.delta
      const slots = this.$slots.default || []

      // item-mode should be decided from items prop.
      if (this.item || this.$scopedSlots.item) {
        delta.total = this.itemcount
        if (delta.keeps > delta.total) {
          delta.end = delta.total - 1
        }
      } else {
        if (!slots.length) {
          delta.start = 0
        }
        delta.total = slots.length
      }

      let paddingTop, paddingBottom, allHeight
      const hasPadding = delta.total > delta.keeps

      if (this.variable) {
        allHeight = this.getVarAllHeight()
        paddingTop = hasPadding ? this.getVarPaddingTop() : 0
        paddingBottom = hasPadding ? this.getVarPaddingBottom() : 0
      } else {
        allHeight = this.size * delta.total
        paddingTop = this.size * (hasPadding ? delta.start : 0)
        paddingBottom = this.size * (hasPadding ? delta.total - delta.keeps : 0) - paddingTop
      }

      if (paddingBottom < this.size) {
        paddingBottom = 0
      }

      delta.paddingTop = paddingTop
      delta.paddingBottom = paddingBottom
      delta.offsetAll = allHeight - this.size * this.remain

      const renders = []
      for (let i = delta.start; i < delta.total && i <= Math.ceil(delta.end); i++) {
        let slot = null
        if (this.$scopedSlots.item) {
          slot = this.$scopedSlots.item(i)
        } else if (this.item) {
          slot = h(this.item, this.itemprops(i))
        } else {
          slot = slots[i]
        }
        renders.push(slot)
      }

      return renders
    }
  },

  render (h) {
    const dbc = this.debounce
    let list = this.filter(h)
    const { paddingTop, paddingBottom } = this.delta

    const istable = this.istable
    const wtag = istable ? 'div' : this.wtag
    const rtag = istable ? 'div' : this.rtag
    if (istable) {
      list = [h('table', [h('tbody', list)])]
    }
    const renderList = h(wtag, {
      style: Object.assign({
        display: 'block',
        'padding-top': paddingTop + 'px',
        'padding-bottom': paddingBottom + 'px'
      }, this.wstyle),
      class: this.wclass,
      attrs: {
        role: 'group'
      }
    }, list)

    // page mode just render list, no wrapper.
    if (this.pagemode || this.scrollelement) {
      return renderList
    }

    return h(rtag, {
      ref: 'vsl',
      style: {
        display: 'block',
        //'overflow-y': this.size >= this.remain ? 'auto' : 'initial',
        'overflow-y': 'auto',
        height: this.size * this.remain + 'px'
      },
      on: {
        '&scroll': dbc ? _debounce(this.onScroll.bind(this), dbc) : this.onScroll
      }
    }, [
      renderList
    ])
  }
}

